GitHub Actions에서 비밀값 노출 막기

GitHub Actions에서 비밀값 노출 막기

한눈에 보기

CI secret은 암호화해 저장하는 것만으로 안전해지지 않는다. Secret을 받은 runner에서 실행되는 모든 code와 action은 값을 읽을 수 있다. Workflow event, checkout한 code, third-party action, cache와 artifact, self-hosted runner를 하나의 신뢰 경계로 봐야 한다. 기본 GITHUB_TOKEN 권한을 읽기로 낮추고 필요한 job만 승격하며, production은 environment 승인과 branch 제한을 둔다. Cloud에는 장기 key 대신 OIDC로 짧은 자격증명을 발급하고, action은 검토한 full commit SHA로 고정한다.

목차

Secret 저장과 Secret 사용은 다른 문제다

GitHub Actions secret은 repository, organization, environment 범위에 저장할 수 있다. Workflow에서 명시적으로 전달하면 runner의 process가 값을 사용한다.

- name: Publish package
  env:
    PACKAGE_TOKEN: ${{ secrets.PACKAGE_TOKEN }}
  run: npm publish

저장소 화면에 평문이 보이지 않는 것과 job에서 안전하다는 것은 별개다. 이 step에서 실행되는 npm, dependency lifecycle script, shell startup file, runner agent는 environment variable을 읽을 수 있다.

flowchart LR
    A[Encrypted secret store]
    B[Workflow job]
    C[Runner environment]
    D[Shell command]
    E[Child process]
    A --> B --> C --> D --> E

Secret을 보호하려면 “어디에 저장했는가”보다 “어떤 code가 어떤 event에서 값을 받을 수 있는가”를 먼저 봐야 한다.

핵심 질문

이 credential을 받은 job 안에서 공격자가 바꿀 수 있는 code가 한 줄이라도 실행되는가?

먼저 Workflow의 신뢰 경계를 그리기

CI 입력을 신뢰 수준에 따라 분류한다.

입력 기본 신뢰
기본 branch workflow review 후 merge된 YAML 상대적으로 높음
Fork PR code source, Makefile, package script 낮음
Event text issue title, PR body, branch name 낮음
Third-party action marketplace repository code 공급자와 ref에 의존
Cache/artifact 이전 workflow가 만든 archive 생성 주체에 의존
Self-hosted runner disk 이전 job의 workspace와 process 재사용 방식에 의존

Deployment workflow가 trusted YAML을 사용하더라도 untrusted artifact를 내려받아 실행하면 경계가 깨진다. Secret이 있는 job에서 다음 동작을 찾는다.

Threat model을 간단한 흐름으로 남겨 두면 review가 쉬워진다.

flowchart TD
    A[Untrusted contributor]
    B[Pull request code and metadata]
    C[Unprivileged test job]
    D[Test result artifact]
    E[Approved deploy job]
    F[Production credential]
    A --> B --> C --> D
    D --> E
    F --> E

D를 deploy에서 실행 가능한 것으로 신뢰하려면 provenance, source commit, workflow identity를 검증하는 추가 계약이 필요하다.

pull_request와 pull_request_target 구분하기

Fork PR의 pull_request workflow는 일반적으로 read-only GITHUB_TOKEN을 받고 repository secret은 전달받지 않는다. PR의 merge code를 test하는 데 적합하다.

name: pull-request-check

on:
  pull_request:

permissions:
  contents: read

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm ci
      - run: npm test

pull_request_target은 base branch의 workflow context에서 실행되어 label이나 comment 같은 privileged 자동화에 쓸 수 있다. 여기서 PR head를 checkout하고 실행하면 공격자가 바꾼 code가 base repository token과 secret에 접근할 수 있다.

# 위험한 구조를 설명하는 예시
on:
  pull_request_target:

steps:
  - uses: actions/checkout@v6
    with:
      ref: ${{ github.event.pull_request.head.sha }}
  - run: npm test

Checkout 자체보다 그 다음에 untrusted package.json script, Makefile, test를 실행하는 순간 공격이 완성된다.

pull_request_target은 PR code를 실행하지 않는 metadata 자동화로 한정한다.

on:
  pull_request_target:
    types: [opened, labeled]

permissions:
  contents: read
  pull-requests: write

jobs:
  label:
    runs-on: ubuntu-latest
    steps:
      - name: Apply reviewed labels
        uses: owner/reviewed-label-action@0123456789abcdef0123456789abcdef01234567

SHA는 형식 설명용 가상 값이다. 실제 action repository에서 검증한 commit을 사용한다.

GITHUB_TOKEN 권한을 Job 단위로 줄이기

Workflow가 시작되면 자동 GITHUB_TOKEN을 사용할 수 있다. 명시하지 않은 기본 권한은 repository나 organization 설정에 영향을 받으므로 YAML에서 최소 권한을 선언한다.

permissions:
  contents: read

배포 job만 필요한 권한을 추가한다.

jobs:
  test:
    permissions:
      contents: read
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6
      - run: npm test

  publish-image:
    needs: test
    permissions:
      contents: read
      packages: write
    runs-on: ubuntu-latest
    steps:
      - run: publish-reviewed-image

write-all을 편의상 주지 않는다. Issue comment가 필요하면 issues: write인지 pull-requests: write인지 API별로 확인한다.

Action도 token에 접근할 수 있다

입력으로 ${{ secrets.GITHUB_TOKEN }}을 넘기지 않아도 action toolkit이 github.token context를 이용할 수 있다. Job permissions가 최종 방어선이다.

Secret을 필요한 Step에만 전달하기

Job 전체 env에 secret을 두면 모든 후속 step과 child process가 상속할 수 있다.

# 범위가 넓다.
jobs:
  deploy:
    env:
      DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}

사용하는 step에만 둔다.

- name: Deploy release
  env:
    DEPLOY_TOKEN: ${{ secrets.DEPLOY_TOKEN }}
  shell: bash
  run: ./scripts/deploy-reviewed-artifact.sh

하지만 repository의 script를 실행한다면 그 script도 credential을 읽을 수 있다. Script가 trusted branch에서 온 것인지 확인한다.

Command-line argument는 process listing과 debug output에 노출될 수 있다.

# 피해야 할 형태
deploy-cli --token "$DEPLOY_TOKEN"

도구가 stdin, protected file descriptor, 공식 credential provider를 지원하면 그것을 사용한다. Temporary credential file이 필요하면 권한을 제한하고 종료 시 지운다. 그래도 runner snapshot과 crash 상황을 고려해야 한다.

로그 마스킹을 완전한 방어로 믿지 않기

GitHub는 등록된 secret과 알려진 credential 형식을 log에서 redaction한다. 보통 ***로 보이지만 자동 마스킹은 유출 방지의 보조 장치다.

다음 변환은 원본과 다른 문자열이 된다.

# 절대 하지 않을 예
echo "$DEPLOY_TOKEN"
printf '%s' "$DEPLOY_TOKEN" | base64
set -x

Shell tracing은 argument와 expanded variable을 출력할 수 있으므로 secret step에서 set -x를 쓰지 않는다. CLI의 verbose/debug mode도 request header를 남기는지 확인한다.

GitHub secret이 아닌 실행 중 생성 값은 필요한 경우 workflow command로 mask할 수 있다.

temporary_value="$(issue_short_lived_value)"
echo "::add-mask::$temporary_value"
echo "TEMPORARY_VALUE=$temporary_value" >> "$GITHUB_ENV"

Mask command 이전에 출력하면 이미 늦다. Mask는 값 사용 권한을 제한하지도 않는다.

Artifact를 확인한다

Console log가 가려져도 test report, crash dump, HTTP trace, build artifact에 credential이 들어갈 수 있다.

외부 입력을 Shell Script에 직접 삽입하지 않기

PR 제목을 expression으로 shell source 안에 넣으면 따옴표와 command syntax를 공격자가 조작할 수 있다.

# 위험한 예
- run: echo "PR title: ${{ github.event.pull_request.title }}"

Expression 결과를 environment variable로 전달하고 shell에서는 quote한다.

- name: Print pull request title
  env:
    PR_TITLE: ${{ github.event.pull_request.title }}
  shell: bash
  run: printf 'PR title: %s\n' "$PR_TITLE"

Branch name, issue body, label, commit message, matrix 값도 외부 입력일 수 있다. 가능하면 shell 대신 검증된 action의 argument API를 쓰고 허용 목록으로 검증한다.

case "$TARGET_ENVIRONMENT" in
  development|staging) ;;
  *)
    echo "unsupported environment" >&2
    exit 64
    ;;
esac

검증 없이 외부 문자열을 file path, command, query, cache key, cloud resource name에 넣지 않는다.

Action을 Full Commit SHA로 고정하기

Third-party action은 runner에서 실행되는 code다. @main과 major tag는 공급자가 가리키는 commit을 바꿀 수 있다.

# 변경 가능한 ref
- uses: vendor/deploy-action@v3

GitHub 공식 보안 문서는 full-length commit SHA 고정을 action의 immutable release 사용 방법으로 안내한다.

- uses: vendor/deploy-action@0123456789abcdef0123456789abcdef01234567
  # reviewed release: v3.4.1

가상 SHA이므로 실제로 동작하지 않는다. Action 원본 repository의 release tag가 가리키는 commit인지 검증하고 dependency update bot이 새 SHA를 PR로 제안하게 한다.

다음도 함께 review한다.

Organization policy로 허용 action과 SHA pinning을 강제할 수 있다.

OIDC로 장기 Cloud Key 없애기

장기 cloud access key를 repository secret에 저장하면 유출 시 회전 전까지 사용할 수 있다. OIDC federation은 job이 GitHub의 identity token을 받아 cloud provider에서 짧은 credential로 교환하게 한다.

sequenceDiagram
    participant J as GitHub job
    participant O as GitHub OIDC
    participant C as Cloud STS

    J->>O: Request identity token
    O-->>J: Signed JWT with claims
    J->>C: Exchange JWT
    C->>C: Validate issuer, audience, subject
    C-->>J: Short-lived credential

Workflow는 OIDC token을 요청할 권한이 필요하다.

jobs:
  deploy:
    permissions:
      contents: read
      id-token: write
    environment: production
    runs-on: ubuntu-latest
    steps:
      - uses: cloud-provider/official-auth-action@0123456789abcdef0123456789abcdef01234567
        with:
          role: sample-production-deployer
      - run: deploy-reviewed-artifact

id-token: write는 그 자체로 cloud resource 쓰기 권한이 아니다. OIDC token 요청을 허용하며 실제 권한은 cloud trust policy와 교환된 role이 결정한다.

OIDC Claim과 Cloud Policy를 함께 제한하기

모든 repository와 branch의 token을 production role이 신뢰하면 장기 key를 없앴을 뿐 권한은 여전히 넓다.

Cloud trust policy에서 최소한 다음 claim을 검토한다.

Claim 성격 제한 예
Issuer GitHub Actions OIDC issuer
Audience 해당 cloud STS
Repository 특정 owner와 repository
Ref 또는 environment production environment
Workflow identity 검토된 reusable workflow

정확한 claim 이름과 subject 형식은 GitHub 및 cloud provider 문서를 따른다. 문자열을 추측해 policy를 배포하지 않는다.

Production role 자체도 최소 권한과 짧은 session duration을 갖게 한다.

OIDC trust: 누가 role을 받을 수 있는가
Role policy: 받은 뒤 무엇을 할 수 있는가
Environment protection: 언제 사람의 승인이 필요한가

세 층이 각각 다른 질문을 담당한다.

Environment 승인과 배포 동시성 사용하기

Production secret과 OIDC deploy job은 environment에 묶는다.

jobs:
  deploy:
    environment:
      name: production
      url: https://app.example.invalid
    concurrency:
      group: production-deploy
      cancel-in-progress: false

Repository 설정에서 다음을 적용할 수 있다.

concurrency는 동시에 두 배포가 state를 덮는 문제를 줄인다. 진행 중 production 배포를 자동 취소하는 것이 안전한지는 배포 도구의 transaction 성질에 따라 판단한다.

승인이 있어도 reviewer가 어떤 artifact와 diff를 승인하는지 보여 줘야 한다. 단순한 “Approve” 버튼이 provenance 검증을 대신하지 않는다.

Cache와 Artifact도 신뢰하지 않은 입력이다

Untrusted PR이 만든 cache나 artifact를 privileged workflow가 받아 실행하면 secret 접근으로 이어질 수 있다.

# 위험할 수 있는 개념 흐름
on:
  workflow_run:
    workflows: ["PR Build"]
    types: [completed]

steps:
  - run: download-pr-artifact
  - run: ./downloaded-artifact/deploy.sh

workflow_run job에 권한과 secret이 있다고 upstream artifact가 trusted가 되는 것은 아니다.

안전한 방향은 다음과 같다.

  1. Untrusted job의 결과는 data로만 읽는다.
  2. Executable artifact는 trusted source commit을 trusted workflow에서 다시 build한다.
  3. Artifact provenance와 digest, producing workflow identity를 확인한다.
  4. Cache에는 credential을 넣지 않고 privileged workflow가 임의 binary를 실행하지 않는다.

관련 cache 경계는 CI에서 의존성 캐시를 안전하게 사용하는 방법에서 더 자세히 다룬다.

Self-hosted Runner의 잔존 상태 막기

Self-hosted runner가 job 사이에 재사용되면 workspace, docker volume, background process, tool cache에 secret이 남을 수 있다. Public repository의 untrusted PR을 내부 network에 접근 가능한 runner에서 실행하면 경계가 더 커진다.

확인할 항목은 다음과 같다.

Cleanup script만으로 부족할 수 있다

공격자 code가 cleanup을 방해하거나 host 권한을 얻을 수 있다. 가능하면 격리된 ephemeral VM/runner를 폐기하는 방식이 강하다.

Hosted runner도 untrusted code와 secret code를 같은 job에 섞지 않는 원칙은 같다.

Reusable Workflow의 Secret 계약 명시하기

Reusable workflow가 secrets: inherit를 사용하면 caller가 가진 값이 넓게 전달될 수 있다.

jobs:
  deploy:
    uses: sample-org/workflows/.github/workflows/deploy.yml@trusted-ref
    secrets: inherit

필요한 secret만 이름으로 계약한다.

jobs:
  deploy:
    uses: sample-org/workflows/.github/workflows/deploy.yml@trusted-ref
    secrets:
      registry_token: ${{ secrets.PRODUCTION_REGISTRY_TOKEN }}

Callee workflow도 workflow_call에 필요한 secret을 선언하고 job permissions를 최소화한다. Caller가 권한을 준다고 callee가 더 큰 권한을 자동으로 만들 수 있다고 가정하지 않는다.

Reusable workflow ref도 trusted commit으로 고정하고 update review를 거친다.

유출이 의심될 때 먼저 회전하기

Log에서 일부가 가려졌다고 안전하다고 조사만 계속하면 안 된다. Secret이 runner에 전달됐고 출력 또는 artifact 가능성이 있다면 먼저 폐기와 회전을 고려한다.

flowchart TD
    A[Exposure suspected]
    B[Revoke or rotate]
    C[Stop affected workflows]
    D[Find usage and scope]
    E[Delete public artifacts and logs]
    F[Audit access]
    G[Fix trust boundary]
    A --> B --> C --> D --> E --> F --> G

Incident checklist:

Log 삭제는 이미 복사된 credential을 무효화하지 못한다. 회전이 우선이다.

보안 시나리오를 자동으로 검증하기

정상 deploy만 시험하지 않는다.

Event와 권한

입력과 공급망

수명과 복구

구현 체크리스트

Workflow

Credential

실행 환경과 대응

마무리

GitHub Actions에서 secret을 안전하게 다룬다는 것은 값을 YAML에 직접 쓰지 않는 것보다 넓다. Secret을 전달받은 runner에서 실행되는 모든 repository code, action, script가 그 값에 접근할 수 있다.

Workflow event와 checkout code의 신뢰 수준을 먼저 나눈다. Fork PR test는 read-only 권한과 secret 없는 환경에서 실행하고, pull_request_target은 PR code를 실행하지 않는 metadata 자동화에 한정한다. GITHUB_TOKEN은 job 단위 최소 권한을 사용한다.

장기 cloud key는 OIDC로 바꾸되 cloud trust policy가 특정 repository와 production environment만 허용하도록 제한한다. Production job에는 environment 승인과 동시성 제어를 두고, third-party action은 검토한 full commit SHA로 고정한다.

마지막으로 마스킹은 노출된 문자열을 덜 보이게 할 뿐 권한을 제한하지 않는다. Cache, artifact, self-hosted runner에 남은 값까지 생각하고, 유출이 의심되면 log를 지우는 것보다 credential을 먼저 회전한다.

관련 노트

참고 자료